# frozen_string_literal: true

module SolidusPromotions
  # Base class for all promotion benefits.
  #
  # A Benefit is the active part of a promotion: once a promotion becomes
  # eligible for a given promotable (order, line item, or shipment), the benefit
  # determines how much discount to apply and produces the corresponding
  # adjustments.
  #
  # Subclasses specialize the discounting target (orders, line items, or
  # shipments) and usually include one of the following mixins to integrate with
  # Solidus' adjustment system:
  # - SolidusPromotions::Benefits::AdjustLineItem
  # - SolidusPromotions::Benefits::AdjustShipment
  # - SolidusPromotions::Benefits::CreateDiscountedItem
  #
  # A benefit can discount any object for which {#can_discount?} returns true.
  # Implementors must provide a calculator via Spree::CalculatedAdjustments and
  # may override methods such as {#adjustment_label}.
  #
  # Usage example
  #
  #   benefit = SolidusPromotions::Benefits::AdjustLineItem.new(promotion: promo)
  #   if benefit.can_discount?(line_item)
  #     discount = benefit.discount(line_item)
  #     # => #<SolidusPromotions::ItemDiscount ...>
  #   end
  #
  # @see SolidusPromotions::Promotion
  # @see Spree::CalculatedAdjustments
  class Benefit < Spree::Base
    include Spree::Preferences::Persistable
    include Spree::CalculatedAdjustments
    include Spree::AdjustmentSource

    before_destroy :remove_adjustments_from_incomplete_orders
    before_destroy :raise_for_adjustments_for_completed_orders

    # @!attribute [rw] promotion
    #   The owning promotion.
    #   @return [SolidusPromotions::Promotion]
    belongs_to :promotion, inverse_of: :benefits
    # @!attribute [rw] original_promotion_action
    #   Back-reference to the original Solidus (Spree) promotion action, when migrated.
    #   @return [Spree::PromotionAction, nil]
    belongs_to :original_promotion_action, class_name: "Spree::PromotionAction", optional: true
    # @!attribute [r] adjustments
    #   Adjustments created by this benefit.
    #   @return [ActiveRecord::Associations::CollectionProxy<Spree::Adjustment>]
    has_many :adjustments, class_name: "Spree::Adjustment", as: :source, dependent: :restrict_with_error
    # @!attribute [r] shipping_rate_discounts
    #   Shipping-rate-level discounts generated by this benefit.
    #   @return [ActiveRecord::Associations::CollectionProxy<SolidusPromotions::ShippingRateDiscount>]
    has_many :shipping_rate_discounts, class_name: "SolidusPromotions::ShippingRateDiscount", inverse_of: :benefit, dependent: :restrict_with_error
    # @!attribute [r] conditions
    #   Conditions attached to this benefit.
    #   @return [ActiveRecord::Associations::CollectionProxy<SolidusPromotions::Condition>]
    has_many :conditions, class_name: "SolidusPromotions::Condition", inverse_of: :benefit, dependent: :destroy

    # @!method self.of_type(type)
    #   Restricts benefits to the given STI type(s).
    #   @param type [String, Symbol, Class, Array<String,Symbol,Class>] a single type or list of types
    #   @return [ActiveRecord::Relation<SolidusPromotions::Benefit>]
    scope :of_type, ->(type) { where(type: Array.wrap(type).map(&:to_s)) }

    # Returns relations that should be preloaded for this condition.
    #
    # Override this method in subclasses to specify associations that should be eager loaded
    # to avoid N+1 queries when computing discounts or performing automations.
    #
    # @return [Array<Symbol>] An array of association names to preload
    def preload_relations
      [:calculator]
    end

    # Whether this benefit can discount the given object.
    #
    # Subclasses must implement this according to the kinds
    # of objects they are able to discount.
    #
    # @param object [Object] a potential adjustable (order, line item, or shipment)
    # @return [Boolean]
    # @see SolidusPromotions::Benefits::AdjustLineItem,
    #      SolidusPromotions::Benefits::AdjustShipment,
    #      SolidusPromotions::Benefits::CreateDiscountedItem
    def can_discount?(object)
      respond_to?(discount_method_for(object))
    end

    # Calculates and returns a discount for the given adjustable object.
    #
    # This method computes the discount amount using the benefit's calculator and returns
    # an ItemDiscount object representing the discount to be applied. If the computed
    # amount is zero, no discount is returned.
    #
    # @param adjustable [Object] The object to calculate the discount for (e.g., LineItem, Shipment, ShippingRate)
    # @param ... [args, kwargs] Additional arguments passed to the calculator's compute method
    #
    # @return [SolidusPromotions::ItemDiscount, nil] An ItemDiscount object if a discount applies, nil if the amount is zero
    #
    # @example Calculating a discount for a line item
    #   benefit.discount(line_item)
    #   # => #<SolidusPromotions::ItemDiscount item: #<Spree::LineItem>, amount: -10.00, ...>
    #
    # @see #compute_amount
    # @see #adjustment_label
    def discount(adjustable, ...)
      if can_discount?(adjustable)
        send(discount_method_for(adjustable), adjustable, ...)
      else
        raise NotImplementedError, "Please implement #{discount_method_for(adjustable)} in your condition"
      end
    end

    def self.inherited(klass)
      def klass.method_added(method_added)
        if method_added == :discount
          Spree.deprecator.warn <<~MSG
            Please refactor `#{name}`. You're defining `#discount`. Instead, define a method for each type of discountable
            that your benefit can discount. For example:
            ```
            class MyBenefit < SolidusPromotions::Benefit
              def can_discount?(discountable)
                discountable.is_a?(Spree::LineItem)
              end

              def discount(order, _options = {})
                amount = compute_amount(line_item, ...)
                return if amount.zero?

                ItemDiscount.new(
                  item: line_item,
                  label: adjustment_label(line_item),
                  amount: amount,
                  source: self
                )
              end
            ```
            can now become
            ```
            class MyBenefit < SolidusPromotions::Benefit
              def discount_line_item(order, ...)
                amount = compute_amount(line_item, ...)
                return if amount.zero?

                ItemDiscount.new(
                  item: line_item,
                  label: adjustment_label(line_item),
                  amount: amount,
                  source: self
                )
              end
            end
            ```
          MSG
        end
        super
      end
      super
    end

    # Computes the discount amount for the given adjustable.
    #
    # Ensures the returned amount is negative and does not exceed the
    # adjustable's discountable amount.
    #
    # @param adjustable [#discountable_amount] the adjustable to compute for
    # @param ... [args, kwargs] additional arguments forwarded to the calculator
    # @return [BigDecimal] a negative amount suitable for creating an adjustment
    def compute_amount(adjustable, ...)
      promotion_amount = calculator.compute(adjustable, ...) || Spree::ZERO
      [adjustable.discountable_amount, promotion_amount.abs].min * -1
    end

    # Builds the localized label for adjustments created by this benefit.
    #
    # @param adjustable [Object]
    # @return [String]
    def adjustment_label(adjustable)
      I18n.t(
        "solidus_promotions.adjustment_labels.#{adjustable.class.name.demodulize.underscore}",
        promotion: SolidusPromotions::Promotion.model_name.human,
        promotion_customer_label: promotion.customer_label
      )
    end

    # Partial path used for admin forms for this benefit type.
    #
    # @return [String]
    def to_partial_path
      "solidus_promotions/admin/benefit_fields/#{model_name.element}"
    end

    def level
      raise NotImplementedError, "Please implement the correct interface, or include one of the `SolidusPromotions::Benefits::OrderBenefit`, " \
        "`SolidusPromotions::Benefits::LineItemBenefit` or `SolidusPromotions::Benefits::ShipmentBenefit` modules"
    end
    deprecate :level, deprecator: Spree.deprecator

    # Returns the set of condition classes that can still be attached to this benefit.
    # Already-persisted conditions are excluded.
    #
    # @return [Set<Class<SolidusPromotions::Condition>>]
    def available_conditions
      possible_conditions - conditions.select(&:persisted?)
    end

    # Returns the calculators allowed for this benefit type.
    #
    # @return [Array<Class>] calculator classes
    def available_calculators
      SolidusPromotions.config.promotion_calculators[self.class] || []
    end

    # Verifies if the promotable satisfies all applicable conditions of this benefit.
    #
    # When dry_run is true, an {SolidusPromotions::EligibilityResults} entry is
    # recorded for each condition with success/error details; otherwise, the
    # evaluation short-circuits on the first failure.
    #
    # @param promotable [Object] the entity being evaluated (e.g., Spree::Order, Spree::LineItem)
    # @param dry_run [Boolean] whether to collect detailed eligibility information
    # @return [Boolean] true when all applicable conditions are eligible
    def eligible_by_applicable_conditions?(promotable, dry_run: false)
      conditions.filter_map do |condition|
        condition.applicable?(promotable) && begin
          eligible = condition.eligible?(promotable)

          break [false] if !eligible && !dry_run

          if dry_run
            if condition.eligibility_errors.details[:base].first
              code = condition.eligibility_errors.details[:base].first[:error_code]
              message = condition.eligibility_errors.full_messages.first
            end
            promotion.eligibility_results.add(
              item: promotable,
              condition: condition,
              success: eligible,
              code: eligible ? nil : (code || :coupon_code_unknown_error),
              message: eligible ? nil : (message || I18n.t(:coupon_code_unknown_error, scope: [:solidus_promotions, :eligibility_errors]))
            )
          end

          eligible
        end
      end.all?
    end

    # All line items of the order that are eligible for this benefit.
    #
    # @param order [Spree::Order]
    # @return [Array<Spree::LineItem>] eligible line items
    def applicable_line_items(order)
      order.discountable_line_items.select do |line_item|
        eligible_by_applicable_conditions?(line_item)
      end
    end

    # Base set of order-level condition classes available to all benefits.
    #
    # These generic order conditions apply regardless of the concrete benefit
    # type, as every benefit ultimately operates within the context of an order.
    # Concrete benefit subclasses may extend or override this to include
    # additional applicable conditions that are specific to their discount
    # target (e.g., line-item or shipment conditions).
    #
    # @return [Set<Class<SolidusPromotions::Condition>>]
    def possible_conditions
      Set.new(SolidusPromotions.config.order_conditions)
    end

    private

    def discount_method_for(adjustable)
      :"discount_#{adjustable.class.name.demodulize.underscore}"
    end

    # Prevents destroying a benefit when it has adjustments on completed orders.
    #
    # Adds an error and aborts the destroy callback chain when such adjustments exist.
    # @api private
    def raise_for_adjustments_for_completed_orders
      if adjustments.joins(:order).merge(Spree::Order.complete).any?
        errors.add(:base, :cannot_destroy_if_order_completed)
        throw(:abort)
      end
    end
  end
end
